diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..5918215 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..5918215 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala deleted file mode 100644 index 29287ba..0000000 --- a/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.core.UserMessage - -/** A class representing the states of a model that needs computation - */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] diff --git a/build.sbt b/build.sbt index 50487b2..f667f03 100644 --- a/build.sbt +++ b/build.sbt @@ -67,13 +67,15 @@ IWDeps.akka.profiles.eventsourcedJdbcProjection ) -lazy val ui = (project in file("ui")) - .enablePlugins(ScalaJSPlugin) +lazy val ui = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Full).in(file("ui")) .settings(name := "iw-support-ui") .settings( IWDeps.useZIO(Test), - IWDeps.laminar, IWDeps.useZIOJson, + IWDeps.zioPrelude + ) + .jsSettings( + IWDeps.laminar, IWDeps.waypoint, IWDeps.urlDsl, IWDeps.laminextCore, @@ -81,29 +83,9 @@ IWDeps.laminextTailwind, IWDeps.laminextValidationCore ) - -lazy val `ui-model` = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("ui/model")) - .settings(name := "iw-support-ui-model") - .dependsOn(core) - -lazy val `ui-components` = (project in file("ui/components")) - .enablePlugins(ScalaJSPlugin) - .settings(name := "iw-support-ui-components") - .settings( - IWDeps.useZIO(Test), - IWDeps.zioPrelude, - IWDeps.laminar, - IWDeps.useZIOJson, - IWDeps.waypoint, - IWDeps.urlDsl, - IWDeps.laminextCore, - IWDeps.laminextUI, - IWDeps.laminextTailwind, - IWDeps.laminextValidationCore - ) - .dependsOn(`ui-model`.js) + .jvmSettings( + libraryDependencies += "org.apache.poi" % "poi-ooxml" % "5.2.1" + ).dependsOn(core) lazy val root = (project in file(".")) .enablePlugins(IWScalaProjectPlugin) @@ -120,8 +102,6 @@ `tapir-support`.jvm, `mongo-support`, `akka-persistence-support`, - ui, - `ui-model`.js, - `ui-model`.jvm, - `ui-components` + ui.js, + ui.jvm ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/File.scala b/ui/components/src/main/scala/works/iterative/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala deleted file mode 100644 index 66eb6e7..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.HtmlRenderable - -given HtmlRenderable[File] with - def toHtml(m: File): HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala deleted file mode 100644 index 9108fee..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFiles, headerActions)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index cc4ee36..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start sm:justify-between"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index c665744..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,145 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils -import works.iterative.core.CzechSupport - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`("w-6 h-6"), - "Otevřít" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then - files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) - else Nil) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .sortBy(_._1)(CzechSupport.czechOrdering) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index 8de85e4..0000000 --- a/ui/components/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,43 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - override def render(u: UploadButton) = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala deleted file mode 100644 index 79aef16..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative -package ui - -import core.{MessageCatalogue, MessageId} -import scala.scalajs.js -import works.iterative.core.UserMessage -import java.text.MessageFormat - -// TODO: support hierarchical json structure -trait JsonMessageCatalogue extends MessageCatalogue: - def messages: js.Dictionary[String] - - override def get(id: MessageId): Option[String] = - assume(messages != null, "Message catalogue must not be null") - messages.get(id.toString) - - override def get(msg: UserMessage): Option[String] = - assume(messages != null, "Message catalogue must not be null") - get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/components/src/main/scala/works/iterative/ui/UIString.scala b/ui/components/src/main/scala/works/iterative/ui/UIString.scala deleted file mode 100644 index b4fc9e9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala deleted file mode 100644 index 046b651..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative.ui.components.dashboard.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Dashboard: - def number(n: Int, color: String, t: String) = - span(cls(color), title(t), s"$n") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala deleted file mode 100644 index a02e1b7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Items.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - ul(role("list"), items.map(a => li(renderItem(a)))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala deleted file mode 100644 index 50987a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala +++ /dev/null @@ -1,66 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Alert: - enum Kind(val color: Color, val icon: String => SvgElement): - case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) - case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) - case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) - - def success(title: String | HtmlElement) = Alert(Kind.Success, title) - - def success(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Success, title, Some(content)) - - def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) - - def warning(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Warning, title, Some(content)) - - def error(title: String | HtmlElement) = Alert(Kind.Error, title) - - def error(title: String | HtmlElement, content: String | HtmlElement) = - Alert(Kind.Error, title, Some(content)) - - given Conversion[Alert, HtmlElement] = _.element - -import Alert.* - -case class Alert( - kind: Kind, - title: String | HtmlElement, - content: Option[String | HtmlElement] = None -): - def element = - div( - cls := "rounded-md p-4", - cls(kind.color.bg(ColorWeight.w50)), - div( - cls := "flex", - div( - cls := "flex-shrink-0", - kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") - ), - div( - cls := "ml-3", - h3( - cls := "text-sm font-medium", - cls(kind.color.text(ColorWeight.w800)), - title match - case t: String => t - case e: HtmlElement => e - ), - content.map(c => - div( - cls := "mt-2 text-sm", - cls(kind.color.text(ColorWeight.w700)), - c match - case t: String => p(t) - case e: HtmlElement => e - ) - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 637c787..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala deleted file mode 100644 index 3262fcb..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -case class CardWithHeader( - title: String, - actions: Modifier[HtmlElement], - content: Modifier[HtmlElement] -): - def element: HtmlElement = - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - div( - cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", - div( - cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", - div( - cls := "ml-4 mt-2", - h3( - cls := "text-lg leading-6 font-medium text-gray-900", - title - ) - ), - div( - cls := "ml-4 mt-2 flex-shrink-0", - actions - ) - ) - ), - content - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala deleted file mode 100644 index 1af2cdf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,78 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - - inline def toCSS(prefix: String)(weight: ColorWeight): String = - if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) - else toCSSWithColorWeight(prefix, weight) - - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala deleted file mode 100644 index 7b7351f..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import com.raquo.laminar.nodes.ReactiveSvgElement -import org.scalajs.dom -import com.raquo.airstream.core.EventStream - -trait HtmlComponent[Ref <: dom.html.Element, -A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) - def render(a: A): ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] - -trait SvgComponent[Ref <: dom.svg.Element, -A]: - extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) - def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala deleted file mode 100644 index d0c324e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import works.iterative.core.MessageCatalogue - -trait ComponentContext: - def messages: MessageCatalogue - def style: StyleGuide diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala deleted file mode 100644 index a2f8519..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,427 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.laminar.nodes.ReactiveSvgElement - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - object outline: - - inline def bell(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") - ) - ) - - inline def `document-add`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - ) - - inline def `external-link`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - ) - - inline def menu(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d("M4 6h16M4 12h16M4 18h16") - ) - ) - - inline def `status-offline`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - ) - - inline def user(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill("none"), - stroke("currentColor"), - viewBox("0 0 24 24"), - xmlns("http://www.w3.org/2000/svg"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth("2"), - d( - "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - ) - - inline def x(extraClasses: String, sw: String = "2"): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - fill("none"), - viewBox("0 0 24 24"), - stroke("currentColor"), - aria.hidden(true), - path( - strokeLineCap("round"), - strokeLineJoin("round"), - strokeWidth(sw), - d("M6 18L18 6M6 6l12 12") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" - ) - ) - - inline def document(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" - ) - ) - - end outline - - object solid: - - inline def annotation(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def exclamation(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden(true), - path( - fillRule := "evenodd", - d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", - clipRule := "evenodd" - ) - ) - - inline def users(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - ) - - inline def `location-marker`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" - ), - clipRule("evenodd") - ) - ) - - inline def calendar(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" - ), - clipRule("evenodd") - ) - ) - - inline def `chevron-right`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" - ), - clipRule("evenodd") - ) - ) - - inline def search(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" - ), - clipRule("evenodd") - ) - ) - - inline def filter(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" - ), - clipRule("evenodd") - ) - ) - - inline def `arrow-narrow-left`(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" - ), - clipRule("evenodd") - ) - ) - - inline def home(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - d( - "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - ) - - inline def paperclip(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - xmlns("http://www.w3.org/2000/svg"), - viewBox("0 0 20 20"), - fill("currentColor"), - aria.hidden(true), - path( - fillRule("evenodd"), - d( - "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" - ), - clipRule("evenodd") - ) - ) - - inline def info(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", - clipRule := "evenodd" - ) - ) - - inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - fill := "currentColor", - viewBox := "0 0 20 20", - xmlns := "http://www.w3.org/2000/svg", - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", - clipRule := "evenodd" - ) - ) - - inline def `x-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", - clipRule := "evenodd" - ) - ) - - inline def `check-circle`(extraClasses: String): SvgElement = - svg( - cls := extraClasses, - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala deleted file mode 100644 index b662394..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala +++ /dev/null @@ -1,8 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Layout: - def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = - div(cls(cctx.style.card), content) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala deleted file mode 100644 index 266060a..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Modal: - def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")), - close - ) - ) - - div( - cls("fixed inset-0 z-20 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - elem - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala deleted file mode 100644 index 2764124..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate -import works.iterative.core.PlainMultiLine -import java.time.Instant - -trait HtmlRenderable[A]: - def toHtml(a: A): Node - extension (a: A) def render: Node = toHtml(a) - -object HtmlRenderable: - given elementValue: HtmlRenderable[HtmlElement] with - def toHtml(a: HtmlElement): Node = a - given stringValue: HtmlRenderable[String] with - def toHtml(v: String): Node = - com.raquo.laminar.nodes.TextNode(v) - given dateValue: HtmlRenderable[LocalDate] with - def toHtml(v: LocalDate): Node = - TimeUtils.formatDate(v) - given instantValue: HtmlRenderable[Instant] with - def toHtml(v: Instant): Node = - TimeUtils.formatDateTime(v) - given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with - def toHtml(v: PlainMultiLine): Node = - p( - v.split("\n") - .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) - .flatten: _* - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala deleted file mode 100644 index f210a37..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala +++ /dev/null @@ -1,50 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait ButtonStyles: - def basic: String - def primary: String - def secondary: String - def positive: String - def negative: String - -trait StyleGuide: - def button: ButtonStyles - def label: String - def cardContent: String - def card: String - def input: String - -object StyleGuide: - object default extends StyleGuide: - override val label: String = - "text-sm font-medium text-gray-500" - override val cardContent: String = "px-4 py-5 sm:p-6" - override val card: String = - "bg-white shadow sm:rounded-md overflow-hidden" - override val input: String = - "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - - override object button extends ButtonStyles: - private def common(extra: String) = - s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" - override val basic: String = - common( - "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" - ) - override val primary: String = - common( - "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" - ) - override val secondary: String = - common( - "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" - ) - override val positive: String = - common( - "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" - ) - override val negative: String = - common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index e20d919..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,86 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "text-yellow-800", - "hover:text-red-800", - "hover:text-amber-800", - "hover:text-green-800", - "hover:text-yellow-800", - "text-red-700", - "text-amber-700", - "text-green-700", - "text-yellow-700", - "text-red-400", - "text-amber-400", - "text-green-400", - "text-yellow-400", - "bg-red-100", - "bg-amber-100", - "bg-green-100", - "bg-yellow-100", - "hover:bg-red-100", - "hover:bg-amber-100", - "hover:bg-green-100", - "hover:bg-yellow-100", - "hover:bg-red-200", - "hover:bg-amber-200", - "hover:bg-green-200", - "hover:bg-yellow-200", - "bg-green-50", - "bg-amber-50", - "bg-red-50", - "bg-yellow-50", - "bg-gray-500", - "bg-red-500", - "bg-orange-500", - "bg-amber-500", - "bg-yellow-500", - "bg-lime-500", - "bg-green-500", - "bg-gray-600", - "bg-red-600", - "bg-orange-600", - "bg-amber-600", - "bg-yellow-600", - "bg-lime-600", - "bg-green-600", - "bg-emerald-600", - "bg-teal-600", - "bg-cyan-600", - "bg-sky-600", - "bg-blue-600", - "bg-indigo-600", - "bg-violet-600", - "bg-purple-600", - "bg-fuchsia-600", - "bg-pink-600", - "bg-rose-600" - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 3b1128d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,94 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.ui.components.tailwind.Icons - -type ValueContent = String | Node -type OptionalValueContent = ValueContent | Option[ValueContent] - -case class LabeledValue(label: String, body: OptionalValueContent): - def content: Option[Node] = body match - case Some(s: String) => Some(s) - case Some(m: Node) => Some(m) - case s: String => Some(s) - case m: Node => Some(m) - case _ => None - -object LabeledValue: - given renderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, V), LabeledValue] with - def apply(v: (String, V)) = - LabeledValue(cctx.messages(v._1), Some(v._2.render)) - - given optionalRenderableToLabeledValue[V: HtmlRenderable](using - cctx: ComponentContext - ): Conversion[(String, Option[V]), LabeledValue] with - def apply(v: (String, Option[V])) = - LabeledValue(cctx.messages(v._1), v._2.map(_.render)) - -// TODO: drop UI string, use MessageId, use builder like FormBuilder -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LabeledValue], - actions: Option[Modifier[HtmlElement]] = None, - close: Option[Modifier[HtmlElement]] = None -): - - private def renderDataRow(value: LabeledValue): Option[HtmlElement] = - value.content.map(c => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", value.label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - c - ) - ) - ) - - def element: HtmlElement = - div( - cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", - close.map(mods => - div( - cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), - button( - cls( - "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - mods, - span(cls("sr-only"), "Close"), - Icons.outline.x("h-6 w-6", "1.5") - ) - ) - ), - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - data.map(renderDataRow).collect { case Some(el) => el } - ) - ), - actions.map(acts => - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div(cls := "px-4 py-5 sm:px-6", acts) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index e02fe4d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,56 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.ComponentContext - -case class ActionButtonStyle( - border: String, - colors: String, - text: String, - focus: String, - extra: String -) - -// TODO: enum? -object ActionButtonStyle: - val default = ActionButtonStyle( - "border border-gray-300", - "bg-white text-gray-700 hover:bg-gray-50", - "text-sm font-medium", - "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "" - ) - -case class ActionButton[A]( - name: MessageId, - action: A, - style: ActionButtonStyle = ActionButtonStyle.default -): - def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = - button( - tpe("button"), - cls("first:ml-0 ml-3"), - cls("py-2 px-4 rounded-md shadow-sm"), - cls(style.border), - cls(style.colors), - cls(style.text), - cls(style.extra), - cls(style.focus), - ctx.messages(s"action.$name.title"), - onClick.mapTo(action) --> actions - ) - -// buttons to attach under for or detail cards -case class ActionButtons[A](actions: List[ActionButton[A]]) - -object ActionButtons: - class Component[A](actions: Observer[A])(using ctx: ComponentContext) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - override def render(v: ActionButtons[A]) = - div( - cls("flex justify-end"), - v.actions.map(_.element(actions)) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala deleted file mode 100644 index e232ae1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,20 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - @deprecated( - "use specific form instance's form method (see form.LabelsOnLeft)" - ) - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index f6afcd0..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - @deprecated("use specific form's 'body' method (see LabelsOnLeft)") - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala deleted file mode 100644 index 2f56234..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import zio.prelude.Validation - -import scala.scalajs.js - -import core.PlainMultiLine -import java.time.format.DateTimeFormatter -import java.time.LocalDate -import scala.util.Try - -trait FormCodec[V, A]: - def toForm(v: V): A - def toValue(r: A): Validated[V] - -object FormCodec: - given FormCodec[String, String] with - def toForm(v: String): String = v - def toValue(r: String): Validated[String] = Validation.fromPredicateWith( - InvalidValue("error.empty.string") - )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) - - given FormCodec[Option[String], String] with - def toForm(v: Option[String]): String = v.getOrElse("") - def toValue(r: String): Validated[Option[String]] = Validation.succeed( - Option(r).map(_.trim).filter(_.nonEmpty) - ) - - given FormCodec[PlainMultiLine, String] with - override def toForm(v: PlainMultiLine): String = v.toString - override def toValue(r: String): Validated[PlainMultiLine] = - PlainMultiLine(r).mapError(e => InvalidValue(e)) - - given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with - override def toForm(v: Option[PlainMultiLine]): String = v match - case Some(t) => t.toString - case _ => "" - override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) - - given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with - val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") - override def toForm(v: Option[LocalDate]): String = - v.map(df.format(_)).getOrElse("") - override def toValue(r: String): Validated[Option[LocalDate]] = - Validation.succeed(Try(LocalDate.parse(r, df)).toOption) - - given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with - override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) - override def toValue(r: Boolean): Validated[Option[Boolean]] = - Validation.succeed(Some(r)) - -trait LowPriorityFormCodecs: - given identityCodec[A]: FormCodec[A, A] with - override def toForm(v: A): A = v - override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index 322e0d8..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - @deprecated("use LabelsOnLeft.fields") - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 3680628..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - @deprecated("use LabelsOnLeft.header") - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala deleted file mode 100644 index 7d21f70..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import core.PlainMultiLine - -import com.raquo.laminar.api.L.{*, given} -import java.time.LocalDate -import works.iterative.ui.components.tailwind.ComponentContext - -trait FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement - - def validate(v: V => Validated[V]): FormInput[V] = - (property: Property[V], updates: Observer[Validated[V]]) => - this.render(property, updates.contramap(_.flatMap(v))) - -object FormInput: - given stringInput: FormInput[String] = Inputs.PlainInput() - given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() - given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() - given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = - TextArea() - given optionLocalDateInput: FormInput[Option[LocalDate]] = - Inputs.OptionDateInput() - given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = - Switch() diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala deleted file mode 100644 index f6f8fa9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormPropertyRow[V]( - property: FormProperty[V], - input: Property[V] => Modifier[Div] -) - -object FormPropertyRow: - extension [V](m: FormPropertyRow[V]) - def element: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.property.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.property.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.input(m.property), - m.property.description.map(d => - p(cls := "mt-2 text-sm text-gray-500", d) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index d41ec7e..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - @deprecated("use specific form's section method (see LabelsOnLeft)") - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala deleted file mode 100644 index e1016e2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import scala.scalajs.js -import com.raquo.laminar.nodes.ReactiveHtmlElement -import java.time.LocalDate - -object Inputs: - - private def inp[V]( - prop: Property[V], - updates: Observer[Validated[V]], - inputType: String, - mods: Option[Modifier[Input]] = None - )(using codec: FormCodec[V, String]): Input = - input( - idAttr := prop.id, - name := prop.name, - tpe := inputType, - cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", - prop.value.map(v => value(codec.toForm(v))), - onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates - ) - - class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): Input = inp(prop, updates, "text") - - class OptionDateInput extends FormInput[Option[LocalDate]]: - override def render( - prop: Property[Option[LocalDate]], - updates: Observer[Validated[Option[LocalDate]]] - ): Input = - inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala deleted file mode 100644 index ba63ea7..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import works.iterative.core.MessageId -import works.iterative.core.UserMessage - -case class InvalidValue(message: UserMessage | HtmlElement) - -object InvalidValue { - def apply(message: MessageId): InvalidValue = InvalidValue( - UserMessage(message) - ) -} diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala deleted file mode 100644 index 2e87c32..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala +++ /dev/null @@ -1,41 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L -import com.raquo.laminar.api.L.{*, given} - -object LabelsOnLeft: - - def fields( - mods: Modifier[HtmlElement]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) - - def header(header: String, description: String): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) - ) - - def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) - - def body(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) - - def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = - L.form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala deleted file mode 100644 index 2ae210d..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala +++ /dev/null @@ -1,27 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -// Property is a named value. -trait Property[V]: - def id: String - // Property identification - def name: String - // Value - def value: Option[V] - -trait PropertyDescription: - // Human label - def label: String - // Larger description - def description: Option[String] - -trait DescribedProperty[V] extends Property[V] with PropertyDescription - -case class FormProperty[V]( - id: String, - name: String, - label: String, - description: Option[String], - value: Option[V] -) extends Property[V] - with PropertyDescription diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala deleted file mode 100644 index a8c3d81..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala +++ /dev/null @@ -1,51 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -import zio.prelude.Validation -import works.iterative.ui.components.tailwind.ComponentContext - -class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) - extends FormInput[V]: - def render( - property: Property[V], - updates: Observer[Validated[V]] - ): HtmlElement = - val initialValue = property.value.map(codec.toForm).getOrElse(false) - val currentValue = Var(initialValue) - div( - currentValue.signal.map(codec.toValue) --> updates, - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- currentValue.signal.map(v => - if v then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- currentValue.signal.map(v => - if v then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(currentValue.signal).map(v => !v) - ) --> currentValue - ), - ctx.messages - .get(property.name) - .map(name => - span( - cls := "ml-3", - idAttr := "active-only-label", - span( - cls := "text-sm font-medium text-gray-900", - name - ) - ) - ) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala deleted file mode 100644 index d41b4ab..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala +++ /dev/null @@ -1,40 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom.html - -class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: - override def render( - prop: Property[V], - updates: Observer[Validated[V]] - ): ReactiveHtmlElement[html.TextArea] = - TextArea.render( - prop.name, - prop.value.map(codec.toForm), - updates.contramap(codec.toValue), - cls( - "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" - ) - ) - -object TextArea: - def render( - fieldName: String, - currentValue: Option[String], - updates: Observer[String], - mods: Modifier[ReactiveHtmlElement[html.TextArea]]* - ): ReactiveHtmlElement[html.TextArea] = - def numberOfLines(s: String) = s.count(_ == '\n') - val changeBus = EventBus[String]() - val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) - textArea( - changeBus.events.map(numberOfLines) --> rowNo, - changeBus.events --> updates, - name := fieldName, - rows <-- rowNo.signal.map(_ + 2), - mods, - currentValue.map(value(_)), - onInput.mapToValue.setAsValue --> changeBus.writer - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala deleted file mode 100644 index 764f650..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package works.iterative -package ui.components.tailwind - -import zio.prelude.Validation - -package object form: - type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54d74f1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - val c = content - - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) - } diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 5998707..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,24 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - // cls(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.orange => "text-orange-800 bg-orange-100" - case Color.yellow => "text-yellow-800 bg-yellow-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 62f69d1..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,188 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - case class TagInfo(text: String, color: Color) - case class ItemProps( - leftProps: Seq[HtmlElement] = Nil, - rightProp: Option[HtmlElement] = None - ) - case class Item( - title: String | HtmlElement, - tag: Option[TagInfo | HtmlElement] = None, - props: Option[ItemProps | HtmlElement] = None - ) - - def title( - text: String, - mod: Option[Modifier[HtmlElement]] = None - ): HtmlElement = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - mod, - text - ) - - def tag(t: Signal[TagInfo]): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls <-- t.map(c => { - // TODO: color.bg(weight) does not render the weight - List( - c.color.toCSSWithColorWeight("bg", ColorWeight.w100), - c.color.toCSSWithColorWeight("text", ColorWeight.w800) - ).mkString(" ") - }), - child.text <-- t.map(_.text) - ) - - def tag(text: String, color: Color): HtmlElement = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - cls( - // TODO: color.bg(weight) does not render the weight - color.toCSSWithColorWeight("bg", ColorWeight.w100), - color.toCSSWithColorWeight("text", ColorWeight.w800) - ), - text - ) - - def leftProp(text: String, icon: SvgElement): HtmlElement = - leftProp(text, Some(icon)) - - def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" - ), - icon.map(_.amend(svg.cls("mr-1.5"))), - text - ) - - def rightProp(text: Signal[String]): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - child.text <-- text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( - content: HtmlElement - ): HtmlElement = - a(cls("block"), cls(classes), mods, content) - - def stickyHeader( - header: Modifier[HtmlElement], - content: Modifier[HtmlElement] - ): HtmlElement = - div( - cls("relative"), - h3( - cls("z-10 sticky top-0"), - cls( - "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" - ), - header - ), - content - ) - - def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = - Toggle(ctx => - stickyHeader( - Seq[Modifier[HtmlElement]](text, ctx.trigger), - children <-- ctx.toggle(content) - ) - ) - - def item(i: Item): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title match - case t: String => title(t) - case e: HtmlElement => e - , - div( - cls := "ml-2 flex-shrink-0 flex", - i.tag.map { - case t: TagInfo => tag(t.text, t.color) - case e: HtmlElement => e - } - ) - ), - i.props.map { - case ip: ItemProps => - div( - cls := "mt-2 sm:flex sm:justify-between", - div(cls("sm:flex"), ip.leftProps), - ip.rightProp - ) - case e: HtmlElement => e - } - ) - ) - - private def frame: ReactiveHtmlElement[dom.html.UList] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - el.amend(cls("divide-y divide-gray-200")) - ) - - def apply[A](f: A => Item): Items[A] = - Items(frame, item).contramap(f) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala deleted file mode 100644 index 0b7841b..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala +++ /dev/null @@ -1,64 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.feeds - -import com.raquo.laminar.api.L.{*, given} -import java.time.Instant -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.temporal.TemporalAccessor -import java.text.DateFormat -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import java.time.format.DateTimeFormatter -import java.time.ZoneId - -object SimpleWithIcons: - def simpleDate(i: TemporalAccessor): HtmlElement = - time( - customHtmlAttr( - "datetime", - StringAsIsCodec - ) := DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.of("CET")) - .format(i), - TimeUtils.formatDate(i) - ) - - def item( - icon: SvgElement, - text: HtmlElement, - date: HtmlElement, - last: Boolean - ): HtmlElement = - li( - div( - cls("relative pb-8"), - if !last then - Some( - span( - cls( - "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" - ), - aria.hidden := true - ) - ) - else None, - div( - cls("relative flex space-x-3"), - div( - span( - cls( - "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" - ), - icon - ) - ), - div( - cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), - div(p(cls("text-sm text-gray-500")), text), - div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) - ) - ) - ) - ) - - def apply(items: Seq[HtmlElement]): HtmlElement = - div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala deleted file mode 100644 index 9c6b8d2..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala +++ /dev/null @@ -1,104 +0,0 @@ -package works.iterative -package ui.components.tailwind.lists.grid_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Color -import works.iterative.ui.components.tailwind.ColorWeight -import works.iterative.ui.components.headless.Items -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object SimpleCards: - - case class Item( - initials: HtmlElement, - body: HtmlElement - ) - - def initials( - text: String, - color: Color, - weight: ColorWeight = ColorWeight.w600 - ): HtmlElement = - div( - cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", - cls(color.toCSSWithColorWeight("bg", weight)), - text - ) - - def iconButton(icon: SvgElement, screenReaderText: String): Button = - button( - tpe := "button", - cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - span(cls := "sr-only", screenReaderText), - icon - ) - - def titleLink( - clicked: Option[Observer[Unit]] = None, - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String, link: String): HtmlElement = - a( - href(link), - clicked.map(onClick.mapTo(()) --> _), - cls(classes), - text - ) - - def title( - classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" - )(text: String): HtmlElement = - div(cls(classes), text) - - def body( - title: HtmlElement, - subtitle: HtmlElement, - button: Option[Button] = None - ): HtmlElement = div( - cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", - div( - cls := "flex-1 px-4 py-2 text-sm truncate", - title, - p(cls := "text-gray-500", subtitle) - ), - div(cls := "flex-shrink-0 pr-2", button) - ) - - private def item(i: Item): HtmlElement = - div( - cls := "col-span-1 flex shadow-sm rounded-md", - i.initials, - i.body - ) - - def header( - text: String, - classes: String = - "text-gray-500 text-xs font-medium uppercase tracking-wide" - )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) - - def frame( - gap: String = "gap-5 sm:gap-6", - cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" - )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = - el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) - - def apply[A](f: A => Item): Items[A] = - Items(frame(), item).contramap(f) - - case class LinkProps(href: String, events: Option[Observer[Unit]] = None) - - def linked[A](l: A => LinkProps)( - f: A => Item - ): Items[A] = - apply(f).map(i => - card => { - val lp = l(i) - a( - cls("block"), - href(lp.href), - lp.events.map(onClick.preventDefault.mapTo(()) --> _), - card - ) - } - ) diff --git a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala deleted file mode 100644 index 5918215..0000000 --- a/ui/components/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala +++ /dev/null @@ -1,62 +0,0 @@ -package works.iterative -package ui.components.tailwind.navigation - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.core.MessageId -import works.iterative.ui.components.tailwind.ComponentContext - -object Tabs: - def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( - updates: Observer[T] - )(using - ctx: ComponentContext - ): HtmlElement = - val m = tabs - .map { case (t, v) => - t.toString -> v - } - .to(Map) - .withDefault(_ => tabs.head._2) - - div( - div( - cls := "sm:hidden", - label(forId := "tabs", cls := "sr-only", "Select a tab"), - select( - idAttr := "tabs", - name := "tabs", - cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", - tabs.map { case (t, _) => - option( - defaultSelected <-- selected.map(t == _), - value := t.toString, - ctx.messages(t) - ) - }, - onChange.mapToValue.map(m(_)) --> updates - ) - ), - div( - cls := "hidden sm:block", - div( - cls := "border-b border-gray-200", - nav( - cls := "-mb-px flex space-x-8", - aria.label := "Tabs", - tabs.map { case (t, v) => - a( - href := "#", - cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", - cls <-- selected.map(s => - if t == s then "border-indigo-500 text-indigo-600 " - else - "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - ), - ctx.messages(t), - onClick.preventDefault.mapTo(v) --> updates - ) - } - ) - ) - ) - ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/File.scala b/ui/js/src/main/scala/works/iterative/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..66eb6e7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/File.scala @@ -0,0 +1,22 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.HtmlRenderable + +given HtmlRenderable[File] with + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFiles, headerActions)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start sm:justify-between"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..c665744 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,145 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils +import works.iterative.core.CzechSupport + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then + files.sortBy(_.name)(CzechSupport.czechOrdering).map(renderRow) + else Nil) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .sortBy(_._1)(CzechSupport.czechOrdering) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..8de85e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,43 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..79aef16 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/JsonMessageCatalogue.scala @@ -0,0 +1,19 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js +import works.iterative.core.UserMessage +import java.text.MessageFormat + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def get(id: MessageId): Option[String] = + assume(messages != null, "Message catalogue must not be null") + messages.get(id.toString) + + override def get(msg: UserMessage): Option[String] = + assume(messages != null, "Message catalogue must not be null") + get(msg.id).map(_.format(msg.args: _*)) diff --git a/ui/js/src/main/scala/works/iterative/ui/UIString.scala b/ui/js/src/main/scala/works/iterative/ui/UIString.scala new file mode 100644 index 0000000..b4fc9e9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala new file mode 100644 index 0000000..046b651 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/dashboard/tailwind/Dashboard.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.dashboard.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Dashboard: + def number(n: Int, color: String, t: String) = + span(cls(color), title(t), s"$n") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala new file mode 100644 index 0000000..a02e1b7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Items.scala @@ -0,0 +1,22 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: ReactiveHtmlElement[dom.html.UList] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + ul(role("list"), items.map(a => li(renderItem(a)))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala new file mode 100644 index 0000000..50987a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Alert.scala @@ -0,0 +1,66 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Alert: + enum Kind(val color: Color, val icon: String => SvgElement): + case Error extends Kind(Color.red, Icons.solid.`x-circle`(_)) + case Warning extends Kind(Color.yellow, Icons.solid.exclamation(_)) + case Success extends Kind(Color.green, Icons.solid.`check-circle`(_)) + + def success(title: String | HtmlElement) = Alert(Kind.Success, title) + + def success(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Success, title, Some(content)) + + def warning(title: String | HtmlElement) = Alert(Kind.Warning, title) + + def warning(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Warning, title, Some(content)) + + def error(title: String | HtmlElement) = Alert(Kind.Error, title) + + def error(title: String | HtmlElement, content: String | HtmlElement) = + Alert(Kind.Error, title, Some(content)) + + given Conversion[Alert, HtmlElement] = _.element + +import Alert.* + +case class Alert( + kind: Kind, + title: String | HtmlElement, + content: Option[String | HtmlElement] = None +): + def element = + div( + cls := "rounded-md p-4", + cls(kind.color.bg(ColorWeight.w50)), + div( + cls := "flex", + div( + cls := "flex-shrink-0", + kind.icon(s"h-5 w-5 ${kind.color.text(ColorWeight.w400)}") + ), + div( + cls := "ml-3", + h3( + cls := "text-sm font-medium", + cls(kind.color.text(ColorWeight.w800)), + title match + case t: String => t + case e: HtmlElement => e + ), + content.map(c => + div( + cls := "mt-2 text-sm", + cls(kind.color.text(ColorWeight.w700)), + c match + case t: String => p(t) + case e: HtmlElement => e + ) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..637c787 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala new file mode 100644 index 0000000..3262fcb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CardWithHeader.scala @@ -0,0 +1,32 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +case class CardWithHeader( + title: String, + actions: Modifier[HtmlElement], + content: Modifier[HtmlElement] +): + def element: HtmlElement = + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + div( + cls := "bg-white px-4 py-5 border-b border-gray-200 sm:px-6", + div( + cls := "-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap", + div( + cls := "ml-4 mt-2", + h3( + cls := "text-lg leading-6 font-medium text-gray-900", + title + ) + ), + div( + cls := "ml-4 mt-2 flex-shrink-0", + actions + ) + ) + ), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..1af2cdf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Color.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + + inline def toCSS(prefix: String)(weight: ColorWeight): String = + if weight == ColorWeight.w__ then toCSSNoColorWeight(prefix) + else toCSSWithColorWeight(prefix, weight) + + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..7b7351f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Component.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement +import org.scalajs.dom +import com.raquo.airstream.core.EventStream + +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..d0c324e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import works.iterative.core.MessageCatalogue + +trait ComponentContext: + def messages: MessageCatalogue + def style: StyleGuide diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..a2f8519 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Icons.scala @@ -0,0 +1,427 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.laminar.nodes.ReactiveSvgElement + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + inline def x(extraClasses: String, sw: String = "2"): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth(sw), + d("M6 18L18 6M6 6l12 12") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + ) + ) + + inline def document(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + ) + ) + + end outline + + object solid: + + inline def annotation(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 13V5a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h3l3 3 3-3h3a2 2 0 002-2zM5 7a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1zm1 3a1 1 0 100 2h3a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def exclamation(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden(true), + path( + fillRule := "evenodd", + d := "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z", + clipRule := "evenodd" + ) + ) + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" + ), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" + ), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" + ), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" + ), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d( + "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" + ), + clipRule("evenodd") + ) + ) + + inline def info(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z", + clipRule := "evenodd" + ) + ) + + inline def `dots-circle-horizontal`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + fill := "currentColor", + viewBox := "0 0 20 20", + xmlns := "http://www.w3.org/2000/svg", + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z", + clipRule := "evenodd" + ) + ) + + inline def `x-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + clipRule := "evenodd" + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls := extraClasses, + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala new file mode 100644 index 0000000..b662394 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Layout.scala @@ -0,0 +1,8 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Layout: + def card(content: Modifier[HtmlElement]*)(using cctx: ComponentContext): Div = + div(cls(cctx.style.card), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala new file mode 100644 index 0000000..266060a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Modal: + def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + close + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + elem + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..2764124 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Renderable.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate +import works.iterative.core.PlainMultiLine +import java.time.Instant + +trait HtmlRenderable[A]: + def toHtml(a: A): Node + extension (a: A) def render: Node = toHtml(a) + +object HtmlRenderable: + given elementValue: HtmlRenderable[HtmlElement] with + def toHtml(a: HtmlElement): Node = a + given stringValue: HtmlRenderable[String] with + def toHtml(v: String): Node = + com.raquo.laminar.nodes.TextNode(v) + given dateValue: HtmlRenderable[LocalDate] with + def toHtml(v: LocalDate): Node = + TimeUtils.formatDate(v) + given instantValue: HtmlRenderable[Instant] with + def toHtml(v: Instant): Node = + TimeUtils.formatDateTime(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Node = + p( + v.split("\n") + .map(t => Seq(com.raquo.laminar.nodes.TextNode(t), br())) + .flatten: _* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala new file mode 100644 index 0000000..f210a37 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/StyleGuide.scala @@ -0,0 +1,50 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait ButtonStyles: + def basic: String + def primary: String + def secondary: String + def positive: String + def negative: String + +trait StyleGuide: + def button: ButtonStyles + def label: String + def cardContent: String + def card: String + def input: String + +object StyleGuide: + object default extends StyleGuide: + override val label: String = + "text-sm font-medium text-gray-500" + override val cardContent: String = "px-4 py-5 sm:p-6" + override val card: String = + "bg-white shadow sm:rounded-md overflow-hidden" + override val input: String = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + override object button extends ButtonStyles: + private def common(extra: String) = + s"inline-flex items-center px-4 py-2 $extra border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75" + override val basic: String = + common( + "border-gray-300 text-gray-700 bg-white enabled:hover:bg-gray-50" + ) + override val primary: String = + common( + "border-transparent text-white bg-indigo-600 enabled:hover:bg-indigo-700" + ) + override val secondary: String = + common( + "border-gray-300 text-indigo-700 bg-indigo-100 enabled:hover:bg-indigo-200" + ) + override val positive: String = + common( + "border-gray-300 text-white bg-green-600 enabled:hover:bg-green-700" + ) + override val negative: String = + common("border-gray-300 text-white bg-red-600 enabled:hover:bg-red-700") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..e20d919 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,86 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "text-yellow-800", + "hover:text-red-800", + "hover:text-amber-800", + "hover:text-green-800", + "hover:text-yellow-800", + "text-red-700", + "text-amber-700", + "text-green-700", + "text-yellow-700", + "text-red-400", + "text-amber-400", + "text-green-400", + "text-yellow-400", + "bg-red-100", + "bg-amber-100", + "bg-green-100", + "bg-yellow-100", + "hover:bg-red-100", + "hover:bg-amber-100", + "hover:bg-green-100", + "hover:bg-yellow-100", + "hover:bg-red-200", + "hover:bg-amber-200", + "hover:bg-green-200", + "hover:bg-yellow-200", + "bg-green-50", + "bg-amber-50", + "bg-red-50", + "bg-yellow-50", + "bg-gray-500", + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-gray-600", + "bg-red-600", + "bg-orange-600", + "bg-amber-600", + "bg-yellow-600", + "bg-lime-600", + "bg-green-600", + "bg-emerald-600", + "bg-teal-600", + "bg-cyan-600", + "bg-sky-600", + "bg-blue-600", + "bg-indigo-600", + "bg-violet-600", + "bg-purple-600", + "bg-fuchsia-600", + "bg-pink-600", + "bg-rose-600" + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..3b1128d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,94 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.Icons + +type ValueContent = String | Node +type OptionalValueContent = ValueContent | Option[ValueContent] + +case class LabeledValue(label: String, body: OptionalValueContent): + def content: Option[Node] = body match + case Some(s: String) => Some(s) + case Some(m: Node) => Some(m) + case s: String => Some(s) + case m: Node => Some(m) + case _ => None + +object LabeledValue: + given renderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, V), LabeledValue] with + def apply(v: (String, V)) = + LabeledValue(cctx.messages(v._1), Some(v._2.render)) + + given optionalRenderableToLabeledValue[V: HtmlRenderable](using + cctx: ComponentContext + ): Conversion[(String, Option[V]), LabeledValue] with + def apply(v: (String, Option[V])) = + LabeledValue(cctx.messages(v._1), v._2.map(_.render)) + +// TODO: drop UI string, use MessageId, use builder like FormBuilder +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LabeledValue], + actions: Option[Modifier[HtmlElement]] = None, + close: Option[Modifier[HtmlElement]] = None +): + + private def renderDataRow(value: LabeledValue): Option[HtmlElement] = + value.content.map(c => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", value.label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + c + ) + ) + ) + + def element: HtmlElement = + div( + cls := "relative bg-white shadow overflow-hidden sm:rounded-lg", + close.map(mods => + div( + cls("absolute top-0 right-0 hidden pt-4 pr-4 sm:block"), + button( + cls( + "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + mods, + span(cls("sr-only"), "Close"), + Icons.outline.x("h-6 w-6", "1.5") + ) + ) + ), + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + data.map(renderDataRow).collect { case Some(el) => el } + ) + ), + actions.map(acts => + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div(cls := "px-4 py-5 sm:px-6", acts) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..e02fe4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,56 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.ComponentContext + +case class ActionButtonStyle( + border: String, + colors: String, + text: String, + focus: String, + extra: String +) + +// TODO: enum? +object ActionButtonStyle: + val default = ActionButtonStyle( + "border border-gray-300", + "bg-white text-gray-700 hover:bg-gray-50", + "text-sm font-medium", + "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "" + ) + +case class ActionButton[A]( + name: MessageId, + action: A, + style: ActionButtonStyle = ActionButtonStyle.default +): + def element(actions: Observer[A])(using ctx: ComponentContext): HtmlElement = + button( + tpe("button"), + cls("first:ml-0 ml-3"), + cls("py-2 px-4 rounded-md shadow-sm"), + cls(style.border), + cls(style.colors), + cls(style.text), + cls(style.extra), + cls(style.focus), + ctx.messages(s"action.$name.title"), + onClick.mapTo(action) --> actions + ) + +// buttons to attach under for or detail cards +case class ActionButtons[A](actions: List[ActionButton[A]]) + +object ActionButtons: + class Component[A](actions: Observer[A])(using ctx: ComponentContext) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.map(_.element(actions)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..e232ae1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Form.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + @deprecated( + "use specific form instance's form method (see form.LabelsOnLeft)" + ) + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..f6afcd0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + @deprecated("use specific form's 'body' method (see LabelsOnLeft)") + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..2f56234 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,57 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V, A]: + def toForm(v: V): A + def toValue(r: A): Validated[V] + +object FormCodec: + given FormCodec[String, String] with + def toForm(v: String): String = v + def toValue(r: String): Validated[String] = Validation.fromPredicateWith( + InvalidValue("error.empty.string") + )(r)(t => Option(t).map(_.trim).exists(_.nonEmpty)) + + given FormCodec[Option[String], String] with + def toForm(v: Option[String]): String = v.getOrElse("") + def toValue(r: String): Validated[Option[String]] = Validation.succeed( + Option(r).map(_.trim).filter(_.nonEmpty) + ) + + given FormCodec[PlainMultiLine, String] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine], String] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r) + + given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) + + given optionBooleanCodec: FormCodec[Option[Boolean], Boolean] with + override def toForm(v: Option[Boolean]): Boolean = v.getOrElse(false) + override def toValue(r: Boolean): Validated[Option[Boolean]] = + Validation.succeed(Some(r)) + +trait LowPriorityFormCodecs: + given identityCodec[A]: FormCodec[A, A] with + override def toForm(v: A): A = v + override def toValue(r: A): Validated[A] = Validation.succeed(r) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..322e0d8 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + @deprecated("use LabelsOnLeft.fields") + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..3680628 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + @deprecated("use LabelsOnLeft.header") + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..7d21f70 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,29 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate +import works.iterative.ui.components.tailwind.ComponentContext + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + + def validate(v: V => Validated[V]): FormInput[V] = + (property: Property[V], updates: Observer[Validated[V]]) => + this.render(property, updates.contramap(_.flatMap(v))) + +object FormInput: + given stringInput: FormInput[String] = Inputs.PlainInput() + given optionStringInput: FormInput[Option[String]] = Inputs.PlainInput() + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() + given optionBooleanInput(using ComponentContext): FormInput[Option[Boolean]] = + Switch() diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..d41ec7e --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + @deprecated("use specific form's section method (see LabelsOnLeft)") + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala new file mode 100644 index 0000000..e1016e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Inputs.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js +import com.raquo.laminar.nodes.ReactiveHtmlElement +import java.time.LocalDate + +object Inputs: + + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V, String]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..ba63ea7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,15 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import works.iterative.core.MessageId +import works.iterative.core.UserMessage + +case class InvalidValue(message: UserMessage | HtmlElement) + +object InvalidValue { + def apply(message: MessageId): InvalidValue = InvalidValue( + UserMessage(message) + ) +} diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala new file mode 100644 index 0000000..2e87c32 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/LabelsOnLeft.scala @@ -0,0 +1,41 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} + +object LabelsOnLeft: + + def fields( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) + + def header(header: String, description: String): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", description) + ) + + def section(header: HtmlElement, rows: HtmlElement*): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) + + def body(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) + + def form(body: HtmlElement, buttons: HtmlElement): HtmlElement = + L.form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala new file mode 100644 index 0000000..a8c3d81 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/Switch.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +import zio.prelude.Validation +import works.iterative.ui.components.tailwind.ComponentContext + +class Switch[V](using codec: FormCodec[V, Boolean], ctx: ComponentContext) + extends FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement = + val initialValue = property.value.map(codec.toForm).getOrElse(false) + val currentValue = Var(initialValue) + div( + currentValue.signal.map(codec.toValue) --> updates, + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- currentValue.signal.map(v => + if v then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- currentValue.signal.map(v => + if v then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(currentValue.signal).map(v => !v) + ) --> currentValue + ), + ctx.messages + .get(property.name) + .map(name => + span( + cls := "ml-3", + idAttr := "active-only-label", + span( + cls := "text-sm font-medium text-gray-900", + name + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..d41b4ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,40 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V](using codec: FormCodec[V, String]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + TextArea.render( + prop.name, + prop.value.map(codec.toForm), + updates.contramap(codec.toValue), + cls( + "max-w-lg w-full shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + ) + ) + +object TextArea: + def render( + fieldName: String, + currentValue: Option[String], + updates: Observer[String], + mods: Modifier[ReactiveHtmlElement[html.TextArea]]* + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(0)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events --> updates, + name := fieldName, + rows <-- rowNo.signal.map(_ + 2), + mods, + currentValue.map(value(_)), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54d74f1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..5998707 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // cls(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.orange => "text-orange-800 bg-orange-100" + case Color.yellow => "text-yellow-800 bg-yellow-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..62f69d1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,188 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + case class TagInfo(text: String, color: Color) + case class ItemProps( + leftProps: Seq[HtmlElement] = Nil, + rightProp: Option[HtmlElement] = None + ) + case class Item( + title: String | HtmlElement, + tag: Option[TagInfo | HtmlElement] = None, + props: Option[ItemProps | HtmlElement] = None + ) + + def title( + text: String, + mod: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + mod, + text + ) + + def tag(t: Signal[TagInfo]): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls <-- t.map(c => { + // TODO: color.bg(weight) does not render the weight + List( + c.color.toCSSWithColorWeight("bg", ColorWeight.w100), + c.color.toCSSWithColorWeight("text", ColorWeight.w800) + ).mkString(" ") + }), + child.text <-- t.map(_.text) + ) + + def tag(text: String, color: Color): HtmlElement = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + cls( + // TODO: color.bg(weight) does not render the weight + color.toCSSWithColorWeight("bg", ColorWeight.w100), + color.toCSSWithColorWeight("text", ColorWeight.w800) + ), + text + ) + + def leftProp(text: String, icon: SvgElement): HtmlElement = + leftProp(text, Some(icon)) + + def leftProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 first:sm:ml-0 sm:ml-6" + ), + icon.map(_.amend(svg.cls("mr-1.5"))), + text + ) + + def rightProp(text: Signal[String]): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + child.text <-- text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): HtmlElement = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + def link(mods: Modifier[Anchor], classes: String = "hover:bg-gray-50")( + content: HtmlElement + ): HtmlElement = + a(cls("block"), cls(classes), mods, content) + + def stickyHeader( + header: Modifier[HtmlElement], + content: Modifier[HtmlElement] + ): HtmlElement = + div( + cls("relative"), + h3( + cls("z-10 sticky top-0"), + cls( + "border-t border-b border-gray-200 px-6 py-1 bg-gray-50 text-sm font-medium text-gray-500" + ), + header + ), + content + ) + + def stickyHeaderToggle(text: String, content: Seq[HtmlElement]): HtmlElement = + Toggle(ctx => + stickyHeader( + Seq[Modifier[HtmlElement]](text, ctx.trigger), + children <-- ctx.toggle(content) + ) + ) + + def item(i: Item): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title match + case t: String => title(t) + case e: HtmlElement => e + , + div( + cls := "ml-2 flex-shrink-0 flex", + i.tag.map { + case t: TagInfo => tag(t.text, t.color) + case e: HtmlElement => e + } + ) + ), + i.props.map { + case ip: ItemProps => + div( + cls := "mt-2 sm:flex sm:justify-between", + div(cls("sm:flex"), ip.leftProps), + ip.rightProp + ) + case e: HtmlElement => e + } + ) + ) + + private def frame: ReactiveHtmlElement[dom.html.UList] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + el.amend(cls("divide-y divide-gray-200")) + ) + + def apply[A](f: A => Item): Items[A] = + Items(frame, item).contramap(f) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala new file mode 100644 index 0000000..0b7841b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/feeds/SimpleWithIcons.scala @@ -0,0 +1,64 @@ +package works.iterative +package ui.components.tailwind.lists.feeds + +import com.raquo.laminar.api.L.{*, given} +import java.time.Instant +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.temporal.TemporalAccessor +import java.text.DateFormat +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import java.time.format.DateTimeFormatter +import java.time.ZoneId + +object SimpleWithIcons: + def simpleDate(i: TemporalAccessor): HtmlElement = + time( + customHtmlAttr( + "datetime", + StringAsIsCodec + ) := DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.of("CET")) + .format(i), + TimeUtils.formatDate(i) + ) + + def item( + icon: SvgElement, + text: HtmlElement, + date: HtmlElement, + last: Boolean + ): HtmlElement = + li( + div( + cls("relative pb-8"), + if !last then + Some( + span( + cls( + "absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" + ), + aria.hidden := true + ) + ) + else None, + div( + cls("relative flex space-x-3"), + div( + span( + cls( + "h-8 w-8 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-white" + ), + icon + ) + ), + div( + cls("min-w-0 flex-1 pt-1.5 flex justify-between space-x-4"), + div(p(cls("text-sm text-gray-500")), text), + div(cls("text-right text-sm whitespace-nowrap text-gray-500"), date) + ) + ) + ) + ) + + def apply(items: Seq[HtmlElement]): HtmlElement = + div(cls("flow-root"), ul(role("list"), cls("-mb-8"), items)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala new file mode 100644 index 0000000..9c6b8d2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/lists/grid_lists/SimpleCards.scala @@ -0,0 +1,104 @@ +package works.iterative +package ui.components.tailwind.lists.grid_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Color +import works.iterative.ui.components.tailwind.ColorWeight +import works.iterative.ui.components.headless.Items +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object SimpleCards: + + case class Item( + initials: HtmlElement, + body: HtmlElement + ) + + def initials( + text: String, + color: Color, + weight: ColorWeight = ColorWeight.w600 + ): HtmlElement = + div( + cls := "flex-shrink-0 flex items-center justify-center w-16 text-white text-sm font-medium rounded-l-md", + cls(color.toCSSWithColorWeight("bg", weight)), + text + ) + + def iconButton(icon: SvgElement, screenReaderText: String): Button = + button( + tpe := "button", + cls := "w-8 h-8 bg-white inline-flex items-center justify-center text-gray-400 rounded-full bg-transparent hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + span(cls := "sr-only", screenReaderText), + icon + ) + + def titleLink( + clicked: Option[Observer[Unit]] = None, + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String, link: String): HtmlElement = + a( + href(link), + clicked.map(onClick.mapTo(()) --> _), + cls(classes), + text + ) + + def title( + classes: String = "text-gray-900 font-medium hover:text-gray-600 truncate" + )(text: String): HtmlElement = + div(cls(classes), text) + + def body( + title: HtmlElement, + subtitle: HtmlElement, + button: Option[Button] = None + ): HtmlElement = div( + cls := "flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md truncate", + div( + cls := "flex-1 px-4 py-2 text-sm truncate", + title, + p(cls := "text-gray-500", subtitle) + ), + div(cls := "flex-shrink-0 pr-2", button) + ) + + private def item(i: Item): HtmlElement = + div( + cls := "col-span-1 flex shadow-sm rounded-md", + i.initials, + i.body + ) + + def header( + text: String, + classes: String = + "text-gray-500 text-xs font-medium uppercase tracking-wide" + )(content: HtmlElement): HtmlElement = div(h2(cls(classes), text), content) + + def frame( + gap: String = "gap-5 sm:gap-6", + cols: String = "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + )(el: ReactiveHtmlElement[dom.html.UList]): HtmlElement = + el.amend(cls := "mt-3 grid", cls(gap), cls(cols)) + + def apply[A](f: A => Item): Items[A] = + Items(frame(), item).contramap(f) + + case class LinkProps(href: String, events: Option[Observer[Unit]] = None) + + def linked[A](l: A => LinkProps)( + f: A => Item + ): Items[A] = + apply(f).map(i => + card => { + val lp = l(i) + a( + cls("block"), + href(lp.href), + lp.events.map(onClick.preventDefault.mapTo(()) --> _), + card + ) + } + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala new file mode 100644 index 0000000..5918215 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/navigation/Tabs.scala @@ -0,0 +1,62 @@ +package works.iterative +package ui.components.tailwind.navigation + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.core.MessageId +import works.iterative.ui.components.tailwind.ComponentContext + +object Tabs: + def apply[T](tabs: Seq[(MessageId, T)], selected: Signal[MessageId])( + updates: Observer[T] + )(using + ctx: ComponentContext + ): HtmlElement = + val m = tabs + .map { case (t, v) => + t.toString -> v + } + .to(Map) + .withDefault(_ => tabs.head._2) + + div( + div( + cls := "sm:hidden", + label(forId := "tabs", cls := "sr-only", "Select a tab"), + select( + idAttr := "tabs", + name := "tabs", + cls := "block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md", + tabs.map { case (t, _) => + option( + defaultSelected <-- selected.map(t == _), + value := t.toString, + ctx.messages(t) + ) + }, + onChange.mapToValue.map(m(_)) --> updates + ) + ), + div( + cls := "hidden sm:block", + div( + cls := "border-b border-gray-200", + nav( + cls := "-mb-px flex space-x-8", + aria.label := "Tabs", + tabs.map { case (t, v) => + a( + href := "#", + cls := "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + cls <-- selected.map(s => + if t == s then "border-indigo-500 text-indigo-600 " + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" + ), + ctx.messages(t), + onClick.preventDefault.mapTo(v) --> updates + ) + } + ) + ) + ) + ) diff --git a/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala deleted file mode 100644 index 29287ba..0000000 --- a/ui/model/src/main/scala/works/iterative/ui/model/Computable.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.model - -import works.iterative.core.UserMessage - -/** A class representing the states of a model that needs computation - */ -// TODO: move to core when stable -enum Computable[Model]: - case Uninitialized extends Computable[Nothing] - case Computing extends Computable[Nothing] - case Ready(model: Model) extends Computable[Model] - case Failed(error: UserMessage) extends Computable[Nothing] diff --git a/ui/model/src/main/scala/works/iterative/ui/model/Form.scala b/ui/model/src/main/scala/works/iterative/ui/model/Form.scala deleted file mode 100644 index 1fa38d0..0000000 --- a/ui/model/src/main/scala/works/iterative/ui/model/Form.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative -package ui.model - -import core.* - -// Generic events that can be generated by forms -// When editing a form, you can -// - submit the form with some data (might be multiple options) -// - cancel the form editation -// - reset the form to its initial state -enum FormEvent[T]: - case Submitted(data: T) - case Cancelled extends FormEvent[Nothing] - case Reset extends FormEvent[Nothing] - -case class FormItem[Value]( - id: String, - label: PlainOneLine, - description: Option[PlainMultiLine], - value: Value -) - -case class FormSection( - header: PlainOneLine, - description: Option[PlainMultiLine], - items: List[FormItem[_]] -) - -case class Form(sections: List[FormSection])